This is not affiliated with Epic, Unreal, or Trenchbroom in any way, this is just personal project shenanigans
Trenchbroom is a Radiant-type editor. You can think of it as a very lightweight CAD that is great at quickly blocking out models and scenes. If you ever made maps for Doom, Half-Life, Quake, or COD, you’ve used a Radiant before.
Radiants are usually quite old, they’ve mostly been supplanted by level designers doing everything in 3ds, zbrush, or blender. But for those of us who grew up with that scene, it’s a skill that’s hard to beat with more sophisticated tools. Trenchbroom is the most modern Radiant, and the one I use. UE, however, has absolutely no support for the maps created with trenchbroom.
Trenchbroom supports many games, but nothing for UE4. For our purposes the following is a workable game definition, to be placed in <trenchbroom install dir>/games/UE4. For example, mine is C:\Program Files (x86)\trenchbroom\dev\games\UE4\GameConfig.cfg on windows.
{
version: 3,
name: "UE4",
icon: "Icon.png",
"fileformats": [
{ "format": "Standard", "initialmap": "initial_standard.map" }
],
"filesystem": {
"searchpath": ".",
"packageformat": { "extension": "pak", "format": "idpak" }
},
"textures": {
"package": { "type": "directory", "root": "textures" },
"format": { "extensions": ["jpg", "jpeg", "tga", "png"], "format": "image" },
"attribute": "_tb_textures"
},
"entities": {
"definitions": [ "trenchbroom.fgd" ],
"defaultcolor": "0.6 0.6 0.6 1.0",
"modelformats": [ "bsp, mdl, md2" ]
},
"tags": {
"brush": [],
"brushface": []
},
"faceattribs": {
"surfaceflags": [],
"contentflags": []
}
}
This is all fairly boilerplate, and almost none of it actually affects what we do. The only part that’s notable is the textures.package.root, which defines the name of the directory under which TB will look for textures. This becomes important in the next part.
With a radiant, texture images are applied, scaled, and offset per-face or per-brush. Images have to be loaded (in "collections", which just means a directory) from disk before being applied. The setup for this should look like;
game
|- Source
|- Binaries
|- Content
|--|- textures
|--|--|- test1
|--|--|--|- T_sometexture1.png
|--|--|--|- T_texture2.png
|--|--|--|- MI_sometexture1
|--|--|--|- MI_texture2
Where T_ are the actual texture files you create (by whatever means), and MI_ are the material instances that are to be used for the texture ingame. This nomenclature and structure becomes important, because our compilation/export process depends on these conventions - it needs to be able to strip directory names and convert references to textures to material instances so that UE4 knows what to do.
Unfortunately, you’ll still have to manually make the material instances yourself, the export process cannot do that for you. It’s a one-time-per-texture cost, and not very high at that, so it’s acceptable.
Note that the maps/models you create do not have to be located in textures/, and probably shouldn’t be. UE4 has a "global search" option when importing FBX, so these material instances will be found no matter where you create them. TB, however, needs to have them under a specific textures folder.
In TB itself, you’ll have to Reload Texture Collections (default F5, but it’s under the tools menu) to pick up changes to textures, or new textures.
Functionally this breaks down into three pieces:
(highly recommend binding export and compilation to F-keys)
Also worth noting that there’s a task and PR to add obj export to compile, which would be killer - but it hasn’t been merged. Building TB on windows requires Qt which is a paid product and… it’s suddenly not worth it. Just bind the key and hit two keys until this gets done.
Working directory should be ${MAP_DIR_PATH}
Parameters should be ${MAP_BASE_NAME} ${GAME_DIR_PATH}
Tool should be a file containing the following; (for windows, bash equivalent would be way simpler)
@echo off
set "blendname=%1%.blend"
set "toolpath=%2%\..\..\tools\mapExport.py"
set "emptyblend=%2%\..\..\tools\empty.blend"
if not exist %blendname% (
echo copying %emptyblend%
echo to %blendname%
copy %emptyblend% %blendname%
)
blender %blendname% -b --python %toolpath%
import bpy
import bmesh
import os
import glob
def main():
if bpy.context.mode != 'OBJECT':
bpy.ops.object.mode_set()
deleteAll()
cleanup()
importObj()
joinObjects()
renameObj()
renameMaterials()
deleteEmptyFaces()
exportFBX()
# save all changes.
bpy.ops.wm.save_as_mainfile()
#deleteObj()
def deleteAll():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# removes all materials, so that there are no name collisions
def cleanup():
i = 0
for mat in bpy.data.materials:
mat.name = "_unused" + str(i)
bpy.data.materials.remove(mat)
# imports the wavefront obj from the same dir
def importObj():
path = os.path.dirname(bpy.context.blend_data.filepath)
modelpath = path + "/" + modelname() + ".obj"
bpy.ops.import_scene.obj(filepath=modelpath)
# renames the joined object to be the same as the filename
def renameObj():
bpy.data.objects[0].name = modelname()
# joins all objects together into one (so that one map = 1 mesh)
def joinObjects():
ctx = bpy.context.copy()
ctx['active_object'] = bpy.data.objects[0]
obs = []
for ob in bpy.context.scene.objects:
if ob.type == 'MESH':
obs.append(ob)
ctx['active_object'] = obs[0]
ctx['selected_editable_objects'] = obs
bpy.ops.object.join(ctx)
# renames materials to not have folder names or blender suffixes
# (so that they map cleanly to uasset materials)
def renameMaterials():
for obj in bpy.data.objects:
for i, matslot in list(enumerate(obj.material_slots)):
if matslot.material:
# remove blender nonsense
matname = matslot.material.name
matname = os.path.splitext(matname)[0]
# remove any leading slashes
slashidx = matname.find('/')
if(slashidx >= 0):
matname = matname[slashidx+1:len(matname)]
# rename from T_ to MI_, so that UE4 import brings in the instance.
if(matname.startswith('T_')):
matname = matname.replace("T_", "MI_", 1)
matslot.material.name = matname
continue
# and make sure __TB_empty doesnt have a suffix
if(matname.startswith('__TB_empty')):
matslot.material.name = '__TB_empty'
continue
def deleteEmptyFaces():
bpy.ops.object.select_all(action='DESELECT')
emptyMat = -1
# find empty material
for obj in bpy.data.objects:
for i, matslot in list(enumerate(obj.material_slots)):
if matslot.material:
if "__TB_empty" in matslot.name:
emptyMat = i
break
if emptyMat == -1:
return
for obj in bpy.data.objects:
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
mesh = bmesh.from_edit_mesh(obj.data)
mesh.select_mode = {"FACE"}
deadFaces = []
for face in mesh.faces:
if face.material_index == emptyMat:
deadFaces.append(face)
bmesh.ops.delete(mesh, geom=deadFaces, context='FACES_ONLY')
bpy.ops.object.mode_set(mode='OBJECT')
def exportFBX():
exportdir = os.path.dirname(bpy.context.blend_data.filepath)
name = modelname() + ".fbx"
exportpath = os.path.join(exportdir, name)
bpy.ops.export_scene.fbx(
filepath=exportpath,
use_selection=False,
apply_scale_options='FBX_SCALE_ALL',
axis_up="Z"
)
def deleteObj():
basepath = os.path.splitext(bpy.context.blend_data.filepath)[0]
objpath = basepath + ".obj"
mtlpath = basepath + ".mtl"
os.remove(objpath)
os.remove(mtlpath)
def modelname():
filename = bpy.path.basename(bpy.context.blend_data.filepath)
name = os.path.splitext(filename)[0]
return name
main()